﻿/* 
 * Copyright (C) 2011 by Zachary J. Gramana
 */
using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Remoting;
using System.Security;
using System.Security.Permissions;
using System.Security.Policy;
using System.Text;
using System.Threading;
using Microsoft.CSharp;
using SharpScript.Properties;

namespace SharpScript
{
    public class ScriptHost : MarshalByRefObject, IDisposable
    {
        #region Instance Fields
        // ####################################################################
        // ###### Instance Fields
        // ####################################################################
        private readonly dynamic            _context;
        private          HostConfiguration  _currentConfiguration;
        private          Boolean            _runLock;
        private static   AppDomain          _scriptDomain;
        #endregion

        #region Constructors
        // ####################################################################
        // ###### Constructors
        // ####################################################################
        public ScriptHost() : this(null, null) {}
        public ScriptHost(HostConfiguration hostConfiguration, dynamic context)
        {
            _runLock = false;
            _context = context;
            _currentConfiguration = hostConfiguration ?? new HostConfiguration();
        }

        #endregion

        #region Events
        // ####################################################################
        // ###### Events
        // ####################################################################
        /// <summary>
        /// Occurs when a script successfully compiles.
        /// </summary>
        public event EventHandler<CompileEventHandlerArgs> CompileSucceeded;
        /// <summary>
        /// Occurs when a script fails to compile.
        /// </summary>
        public event EventHandler<CompileEventHandlerArgs> CompileFailed;
        /// <summary>
        /// Occurs when a runtime error thrown by a script is caught.
        /// </summary>
        public event EventHandler<ScriptRunTimeExceptionEventHandlerArgs> RuntimeException;

        private void OnCompiledSucceeded(CompileEventHandlerArgs e)
        {
            var handler = Interlocked.CompareExchange(ref CompileSucceeded, null, null);
            if (handler != null) handler(this, e);
        }

        private void OnCompileFailed(CompileEventHandlerArgs e)
        {
            var handler = Interlocked.CompareExchange(ref CompileFailed, null, null);
            if (handler != null) handler(this, e);
            _runLock = false;
        }

        private void OnRuntimeException(ScriptRunTimeExceptionEventHandlerArgs e)
        {
            var handler = Interlocked.CompareExchange(ref RuntimeException, null, null);
            if (handler != null) handler(this, e);
            _runLock = false;
        }

        #endregion

        #region Properties
        // ####################################################################
        // ###### Properties
        // ####################################################################
        /// <summary>
        /// Additional assemblies to link into the executed assembly.
        /// </summary>        
        public HostConfiguration CurrentConfiguration
        {
            set
            {

                if (value == null)
                    throw new NoNullAllowedException("CurrentConfiguration cannot be set to null.");

                if (_currentConfiguration.CompareTo(value) == 0) return;

                // Make sure we are making a valid call.
                HandleConfigurationChange(value, "CurrentConfiguration");
            }
            get { return _currentConfiguration; }
        }

        private void CurrentConfigurationChanged(object sender, AppDomainSettingsChangedHandlerArgs e)
        {
            // Since the configuration has changed, we need to
            HandleConfigurationChange(null, e.SourceProperty);
        }

        private void HandleConfigurationChange(HostConfiguration configuration, String changingProperty)
        {
            if (_runLock)
                throw new InvalidOperationException("The script host configuration cannot be modified while a script is running.");

            // If we were called by CurrentConfiguration's set we,
            // we need to do our checks.
            if (changingProperty.CompareTo("CurrentConfiguration") == 0)
            {
                // If we were passed a new host configuration, then we'll update it here.
                _currentConfiguration = configuration;
                _currentConfiguration.ScriptDomainConfigurationChanged += CurrentConfigurationChanged;
            }

            if (ScriptDomain == null) return;
            // A new AppDomain will be created the next time the script is run.
            // This prevents us from thrashing around when the user is
            // making a series of changes.
            AppDomain.Unload(ScriptDomain);
            ScriptDomain = null;
            
       }

        private static AppDomain ScriptDomain
        {
            get
            {
                AppDomain value = null;
                if (_scriptDomain != null)
                {
                    lock(_scriptDomain)
                    {
                        value = _scriptDomain;
                    }
                }
                return value;
            }
            set
            {
                if (_scriptDomain != null)
                {
                    lock(_scriptDomain)
                    {
                        _scriptDomain = value;
                    }
                }
                else
                {
                    _scriptDomain = value;
                }
                return;
            }
        }
        #endregion

        #region Instance Methods
        // ####################################################################
        // ###### Instance Methods
        // ####################################################################

        /// <summary>
        /// Compiles our user's script code.
        /// </summary>
        /// <param name="userScript"></param>
        /// <returns></returns>
        public CompilerResults Compile(String userScript)
        {
            var compilerInfo = new Dictionary<String, String>
                                   {
                                       {"CompilerVersion", "v4.0"}
                                   };

            var codeProvider = new CSharpCodeProvider(compilerInfo);
            var userNamespaces = new StringBuilder();
            var sharpScriptCode = new StringBuilder();

            foreach (var userNamespace in CurrentConfiguration.AdditionalNamespaces)
            {
                userNamespaces.AppendLine(String.Format("using {0};", userNamespace));
            }

            // NOTE.ZJG: This is the place to insert any pre-processing we might want.
            var classCodeTemplate = Resources.ScriptClassTemplate;
            sharpScriptCode.Append(String.Format(classCodeTemplate, userScript, userNamespaces));

            // Here we actually compile.
            var compilerResults = codeProvider.CompileAssemblyFromSource
                                    (
                                        GetCompilerParameters(),
                                        sharpScriptCode.ToString()
                                    );

            if (compilerResults.Errors.HasErrors)
            {
                OnCompileFailed(new CompileEventHandlerArgs(compilerResults));
            }
            else
            {
                OnCompiledSucceeded(new CompileEventHandlerArgs(compilerResults));

                // Replace the loaded assembly. This will remove the
                // open file reference to the compiled script assembly,
                // once we're out of scope, allowing us to delete it.
                var scriptAssemblyBytes = File.ReadAllBytes(compilerResults.PathToAssembly);
                compilerResults.CompiledAssembly = Assembly.Load(scriptAssemblyBytes);
            }
            
            return compilerResults;
        }

        /// <summary>
        /// Defines our compilation parameters.
        /// </summary>
        /// <returns></returns>
        private CompilerParameters GetCompilerParameters()
        {
            var parameters = new CompilerParameters();

            // Add in our "standard" assemblies.
            // NOTE.ZJG: Here lie the default assemblies we link to.
            
            // Even though SharpScript.dll is in the GAC, we must
            // provide the full path, otherwise we get a FileNotFoundException.
            var assemblies = new List<String>
                                 {
                                     "System.dll",
                                     "System.Data.dll",
                                     "System.Data.DataSetExtensions.dll",
                                     "System.Core.dll",
                                     "System.Xml.dll",
                                     "Microsoft.CSharp.dll",
                                     Assembly.GetExecutingAssembly().Location
                                 };

            parameters.ReferencedAssemblies.AddRange(assemblies.ToArray());

            // Now add in our user-supplied assemblies, 
            // which should be full files paths.
            if (CurrentConfiguration.AdditionalAssemblies.Count > 0)
            {
                foreach (var additionalAssembly in CurrentConfiguration.AdditionalAssemblies)
                {
                    parameters.ReferencedAssemblies.Add(additionalAssembly);
                }
            }

            // TODO.ZJG: Would be cool to allow a standalone executable
            //            to be saved to a user-specified file! Just need
            //            set the parameters.MainClass prop to our script class.
            //            We would need to use a slightly different template, in
            //            order to provide a .Main method.
            //parameters.GenerateExecutable = false;

            // We used to set this to true, until we discovered two things:
            //
            // 1. There is no other way to get an assembly loaded into an
            //    AppDomain than by loading it from disk (or the net).
            //
            // 2. GenerateInMemory lies. It still saves the generated
            //    assembly to disk. It reads the bytes, then deletes
            //    the file.
            //
            // Given (1), we now set this to false, and take on the 
            // responsibility of removing the file on disk when the
            // AppDomain unloads.  We also need to shadow copy this,
            // when requested, so that a user may reuse an appdomain
            // but still recompile the assembly.

            // TODO.ZJG: Support assembly reuse via versioning + shadow copying.
            // parameters.GenerateInMemory = true;
            
            // Enable the line of code below to do cool tricks, like
            // keeping the assembly so we can load new assem in another app domain.
            // or keeping the .cs file we generate to reuse in another app.
            // parameters.TempFiles.KeepFiles = true;

            if (CurrentConfiguration.Debug)
            {
                parameters.IncludeDebugInformation = true; // Create a pdb.
            }
            else
            {
                parameters.IncludeDebugInformation = false;
                parameters.CompilerOptions = "/optimize"; // Optimize for performance, not debugging.
            }

            return parameters;
        }

        /// <summary>
        /// Executes the provided script code.
        /// </summary>
        /// <param name="usersScriptCode"></param>
        /// <returns></returns>
        public ScriptResult Run(String usersScriptCode)
        {
            _runLock = true;

            var scriptInfo = Compile(usersScriptCode);
			
            if (scriptInfo.Errors.HasErrors) return new ScriptResult { ConsoleOutput = String.Empty, ResultCode = -1 };
            
            // Define our sandbox's permissions if none were supplied.
            PermissionSet perms = null;
            if (CurrentConfiguration.SandboxSettings == null)
            {
                // NOTE.ZJG: These are currently the the bare minimums.
                perms = new PermissionSet(PermissionState.None);
                perms.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
                perms.AddPermission(new FileIOPermission(PermissionState.None));
            }
            else
            {
                perms = CurrentConfiguration.SandboxSettings.PermissionsGranted;
            }
            
            var compiledScriptAssemblyPath = scriptInfo.PathToAssembly;

            var scriptRoot = CurrentConfiguration.SandboxSettings != null ? 
                                          CurrentConfiguration.SandboxSettings.ScriptRootPath :
                                          null;

            var domainSetup = new AppDomainSetup
                                  {
                                      ApplicationBase = scriptRoot ?? compiledScriptAssemblyPath,
                                      LoaderOptimization = LoaderOptimization.SingleDomain,
                                      ShadowCopyFiles = "true",
                                      ShadowCopyDirectories = new FileInfo(Assembly.GetExecutingAssembly().Location).DirectoryName
                                  };

            // Allow the types from our host types to have full trust,
            // even if the caller defines a partial-trust permission set.
            var trustedHostStrongName = typeof(ScriptHost).Assembly.Evidence.GetHostEvidence<StrongName>();

            var thresholdExceeded = false;

            // If the threshold monitoring is set, and we've exceeded it
            // we will create a new script domain.
            if (ScriptDomain != null && AppDomain.MonitoringIsEnabled && ScriptDomain.MonitoringSurvivedMemorySize != 0 && ScriptDomain.MonitoringSurvivedMemorySize >= CurrentConfiguration.NewScriptDomainMemoryThreshhold)
                thresholdExceeded = true;

            if (thresholdExceeded || ScriptDomain == null || !CurrentConfiguration.ReuseAppDomain)
            {
                if (ScriptDomain != null) AppDomain.Unload(ScriptDomain);

                ScriptDomain = AppDomain.CreateDomain
                    (
                        "ScriptingDomain",
                        null,
                        domainSetup,
                        perms,
                        trustedHostStrongName
                    );


                // The SharpScript.dll gets loaded from the GAC.
                // The other assemblies will get passed into the
                // script domain where the resolver will re-
                // hydrate them if needed.
                var executingAssemblyName = Assembly.GetCallingAssembly().GetName().Name;
                var contextAssemblyName = String.Empty;

               if (_context != null)
                {
                    var contextType = _context.GetType();
                    if (contextType != null)
                    {
                        var contextAssembly = contextType.Assembly;
                        if (contextAssembly != null)
                        {
                            contextAssemblyName = contextAssembly.GetName().Name;
                            ScriptDomain.SetData(contextAssemblyName, 
                                File.ReadAllBytes(_context.GetType().Assembly.Location));
                        }
                    }
                }

                // Add all assemblies referenced by the caller's assembly.
                var assemblyPaths =
                    Assembly.GetCallingAssembly()
                        .GetReferencedAssemblies()
                        .Select(
                            x =>
                                {
                                    var asm = Assembly.Load(x);
                                    return asm.GlobalAssemblyCache ? null : asm.Location;
                                }
                        )
                        .Where(x => x != null)
                        .ToList();

                assemblyPaths.AddRange(CurrentConfiguration.AdditionalAssemblies);

                foreach (var assembly in assemblyPaths)
                {
                    var fileInfo = new FileInfo(assembly);
                    var name = fileInfo.Exists ? fileInfo.Name.Remove(fileInfo.Extension, true) : String.Empty;
                    if ((String.IsNullOrEmpty(name)) 
                        || (String.CompareOrdinal(executingAssemblyName, name) == 0)
                        || (String.CompareOrdinal(contextAssemblyName, name) == 0)
                        ) continue;
                    ScriptDomain.SetData(name, File.ReadAllBytes(fileInfo.FullName));
                }

                ScriptDomain.AssemblyResolve += RuntimeContext.ResolveAssembly;
                
                ScriptDomain.DomainUnload += ScriptDomainUnloaded;
            }

            // Push the script assemblies bytes into the script domain
            // where it can be re-hyrated by the script loader.
            var scriptAssemblyBytes = File.ReadAllBytes(scriptInfo.PathToAssembly);
            ScriptDomain.SetData(scriptInfo.CompiledAssembly.GetName().Name, scriptAssemblyBytes);
            
            // Now we can remove the temporary assembly file, since it 
            // will now live on only in memory.
            File.Delete(scriptInfo.PathToAssembly);
            
            // Create our runtime context inside of the script domain.
            // It will be in charge of managing script operations from
            // inside of that app domain.  When the user interacts with
            // the script host, we will marshal the appropriate messages
            // over to the runtime context for action.
            ObjectHandle contextHandle = null;
            RuntimeContext context = null;            
            var output = String.Empty;
            try
            {
                // Create our runtime context inside our child appdomain.
                var runtimeContextFullName = typeof (RuntimeContext).FullName;

                // We're doing this null check because our Activator.CreateInstanceFrom
                // call has the type name argument marked as NotNull.
                if (runtimeContextFullName == null) throw new NullReferenceException("The script host could not create a runtime context inside the script domain.");

                contextHandle = Activator.CreateInstanceFrom
                 (
                    ScriptDomain,
                    typeof (RuntimeContext).Assembly.ManifestModule.FullyQualifiedName,
                    runtimeContextFullName,
                    false,
                    BindingFlags.CreateInstance,
                    Type.DefaultBinder,
                    new object[] {scriptInfo.CompiledAssembly.GetName().Name, _context},
                    null,
                    null
                 );

                context = contextHandle.Unwrap() as RuntimeContext;
                
                if (context == null) throw new NullReferenceException("The script host could not create a runtime context inside the script domain.");

                // Now run that script! :)
                output = context.Run();
            }
            catch (Exception exception)
            {
                // Don't crash. Just raise an event instead of an exception.
                OnRuntimeException(new ScriptRunTimeExceptionEventHandlerArgs(exception));
            }
            finally
            {
                // Remove the compiler's temp files, if any were left.
                scriptInfo.TempFiles.Delete();
            }

            _runLock = false;

            return new ScriptResult { ConsoleOutput = output, ResultCode = 1 };
        }
            
        #endregion
        
        #region Static Methods
        public static void  ScriptDomainUnloaded(object sender, EventArgs e) 
        {
            // Remove our handler now, to ensure that the
            // garbage collector can do its job.
            ((AppDomain)sender).AssemblyResolve -= RuntimeContext.ResolveAssembly;
            Trace.WriteLine("ScriptDomain unloaded.");
        }
        #endregion
        
        public void Dispose()
        {
            AppDomain.Unload(ScriptDomain);
        }
    }
}
